diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/app/(main)/websites/[websiteId]/cohorts | |
| download | umami-main.tar.xz umami-main.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/cohorts')
8 files changed, 346 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx new file mode 100644 index 0000000..3f7f872 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx @@ -0,0 +1,21 @@ +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { CohortEditForm } from './CohortEditForm'; + +export function CohortAddButton({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<Plus />} + label={formatMessage(labels.cohort)} + variant="primary" + width="800px" + > + {({ close }) => { + return <CohortEditForm websiteId={websiteId} onClose={close} />; + }} + </DialogButton> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx new file mode 100644 index 0000000..94d62ff --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx @@ -0,0 +1,60 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { messages } from '@/components/messages'; + +export function CohortDeleteButton({ + cohortId, + websiteId, + name, + onSave, +}: { + cohortId: string; + websiteId: string; + name: string; + onSave?: () => void; +}) { + const { formatMessage, labels, FormattedMessage } = useMessages(); + const { mutateAsync, isPending, error, touch } = useDeleteQuery( + `/websites/${websiteId}/segments/${cohortId}`, + ); + + const handleConfirm = async (close: () => void) => { + await mutateAsync(null, { + onSuccess: () => { + touch('cohorts'); + onSave?.(); + close(); + }, + }); + }; + + return ( + <DialogButton + icon={<Trash />} + variant="quiet" + title={formatMessage(labels.confirm)} + width="400px" + > + {({ close }) => ( + <ConfirmationForm + message={ + <FormattedMessage + {...messages.confirmRemove} + values={{ + target: <b>{name}</b>, + }} + /> + } + isLoading={isPending} + error={error} + onConfirm={handleConfirm.bind(null, close)} + onClose={close} + buttonLabel={formatMessage(labels.delete)} + buttonVariant="danger" + /> + )} + </DialogButton> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx new file mode 100644 index 0000000..0799071 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx @@ -0,0 +1,37 @@ +import { CohortEditForm } from '@/app/(main)/websites/[websiteId]/cohorts/CohortEditForm'; +import { useMessages } from '@/components/hooks'; +import { Edit } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import type { Filter } from '@/lib/types'; + +export function CohortEditButton({ + cohortId, + websiteId, + filters, +}: { + cohortId: string; + websiteId: string; + filters: Filter[]; +}) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<Edit />} + variant="quiet" + title={formatMessage(labels.cohort)} + width="800px" + > + {({ close }) => { + return ( + <CohortEditForm + cohortId={cohortId} + websiteId={websiteId} + filters={filters} + onClose={close} + /> + ); + }} + </DialogButton> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx new file mode 100644 index 0000000..c755035 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx @@ -0,0 +1,135 @@ +import { + Button, + Column, + Form, + FormButtons, + FormField, + FormSubmitButton, + Grid, + Label, + Loading, + TextField, +} from '@umami/react-zen'; +import { useMessages, useUpdateQuery, useWebsiteCohortQuery } from '@/components/hooks'; +import { ActionSelect } from '@/components/input/ActionSelect'; +import { DateFilter } from '@/components/input/DateFilter'; +import { FieldFilters } from '@/components/input/FieldFilters'; +import { LookupField } from '@/components/input/LookupField'; + +export function CohortEditForm({ + cohortId, + websiteId, + filters = [], + onSave, + onClose, +}: { + cohortId?: string; + websiteId: string; + filters?: any[]; + showFilters?: boolean; + onSave?: () => void; + onClose?: () => void; +}) { + const { data } = useWebsiteCohortQuery(websiteId, cohortId); + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + + const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery( + `/websites/${websiteId}/segments${cohortId ? `/${cohortId}` : ''}`, + { + type: 'cohort', + }, + ); + + const handleSubmit = async (formData: any) => { + await mutateAsync(formData, { + onSuccess: async () => { + toast(formatMessage(messages.saved)); + touch('cohorts'); + onSave?.(); + onClose?.(); + }, + }); + }; + + if (cohortId && !data) { + return <Loading placement="absolute" />; + } + + const defaultValues = { + parameters: { filters, dateRange: '30day', action: { type: 'path', value: '' } }, + }; + + return ( + <Form + error={getErrorMessage(error)} + onSubmit={handleSubmit} + defaultValues={data || defaultValues} + > + {({ watch }) => { + const type = watch('parameters.action.type'); + + return ( + <> + <FormField + name="name" + label={formatMessage(labels.name)} + rules={{ required: formatMessage(labels.required) }} + > + <TextField autoFocus /> + </FormField> + + <Column> + <Label>{formatMessage(labels.action)}</Label> + <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap> + <Column> + <FormField + name="parameters.action.type" + rules={{ required: formatMessage(labels.required) }} + > + <ActionSelect /> + </FormField> + </Column> + <Column> + <FormField + name="parameters.action.value" + rules={{ required: formatMessage(labels.required) }} + > + {({ field }) => { + return <LookupField websiteId={websiteId} type={type} {...field} />; + }} + </FormField> + </Column> + </Grid> + </Column> + + <Column width="260px"> + <Label>{formatMessage(labels.dateRange)}</Label> + <FormField + name="parameters.dateRange" + rules={{ required: formatMessage(labels.required) }} + > + <DateFilter placement="bottom start" /> + </FormField> + </Column> + + <Column> + <Label>{formatMessage(labels.filters)}</Label> + <FormField name="parameters.filters"> + <FieldFilters websiteId={websiteId} exclude={['path', 'event']} /> + </FormField> + </Column> + + <FormButtons> + <Button isDisabled={isPending} onPress={onClose}> + {formatMessage(labels.cancel)} + </Button> + <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + </> + ); + }} + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx new file mode 100644 index 0000000..6734384 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx @@ -0,0 +1,24 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useWebsiteCohortsQuery } from '@/components/hooks'; +import { CohortAddButton } from './CohortAddButton'; +import { CohortsTable } from './CohortsTable'; + +export function CohortsDataTable({ websiteId }: { websiteId?: string }) { + const query = useWebsiteCohortsQuery(websiteId, { type: 'cohort' }); + + const renderActions = () => { + return <CohortAddButton websiteId={websiteId} />; + }; + + return ( + <DataGrid + query={query} + allowSearch={true} + autoFocus={false} + allowPaging={true} + renderActions={renderActions} + > + {({ data }) => <CohortsTable data={data} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx new file mode 100644 index 0000000..14f366e --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx @@ -0,0 +1,16 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { CohortsDataTable } from './CohortsDataTable'; + +export function CohortsPage({ websiteId }) { + return ( + <Column gap="3"> + <WebsiteControls websiteId={websiteId} allowFilter={false} allowDateFilter={false} /> + <Panel> + <CohortsDataTable websiteId={websiteId} /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx new file mode 100644 index 0000000..5c7ac03 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx @@ -0,0 +1,41 @@ +import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen'; +import Link from 'next/link'; +import { CohortDeleteButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton'; +import { CohortEditButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortEditButton'; +import { DateDistance } from '@/components/common/DateDistance'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { filtersObjectToArray } from '@/lib/params'; + +export function CohortsTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { websiteId, renderUrl } = useNavigation(); + + return ( + <DataTable {...props}> + <DataColumn id="name" label={formatMessage(labels.name)}> + {(row: any) => ( + <Link href={renderUrl(`/websites/${websiteId}?cohort=${row.id}`, false)}>{row.name}</Link> + )} + </DataColumn> + <DataColumn id="created" label={formatMessage(labels.created)}> + {(row: any) => <DateDistance date={new Date(row.createdAt)} />} + </DataColumn> + <DataColumn id="action" align="end" width="100px"> + {(row: any) => { + const { id, name, parameters } = row; + + return ( + <Row> + <CohortEditButton + cohortId={id} + websiteId={websiteId} + filters={filtersObjectToArray(parameters)} + /> + <CohortDeleteButton cohortId={id} websiteId={websiteId} name={name} /> + </Row> + ); + }} + </DataColumn> + </DataTable> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/page.tsx b/src/app/(main)/websites/[websiteId]/cohorts/page.tsx new file mode 100644 index 0000000..9946f60 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { CohortsPage } from './CohortsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <CohortsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Cohorts', +}; |